feat(state): encrypt secrets in state by default (auto local key, KMS on S3) - #1400
Open
its-rosetta wants to merge 5 commits into
Open
feat(state): encrypt secrets in state by default (auto local key, KMS on S3)#1400its-rosetta wants to merge 5 commits into
its-rosetta wants to merge 5 commits into
Conversation
… on S3) Re-implementation of alchemy-run#1030 rebased onto current main. State stores that own their key material now encrypt every Redacted<T> at rest as a { "__secret__": "v1:..." } envelope (AES-256-GCM, per-value IV) instead of the plaintext { "__redacted__": ... } marker: - Local (.alchemy/state/): auto-generated 32-byte key at ~/.alchemy/state.key (mode 0600, wx-exclusive create). - S3 (AWS.state()): KMS envelope encryption via alias/alchemy-state, lazily engaged — secret-free stacks never touch KMS. Recovers the key from a pending deletion. Opt out with secretEncryption: "off". - ALCHEMY_PASSWORD overrides both with a scrypt-derived key. - Legacy plaintext markers still revive; the next write re-encrypts. - alchemy state get/export print __secret__ envelopes, never plaintext. New since alchemy-run#1030: the state export command (added on main in alchemy-run#1043) is covered by the same codec treatment as state get. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes from a three-lens review (crypto, KMS/concurrency, integration): - Codec resolution memoizes on SUCCESS only (S3 and Local): Effect.cached persists a failure Exit forever, so one transient S3/KMS/fs failure poisoned every later secret read/write for the process lifetime. - recoverKmsKey rides out KMS eventual consistency: EnableKey retries through the post-CancelKeyDeletion window instead of swallowing KMSInvalidStateException (which left the key Disabled), then waits for KeyState=Enabled before the Decrypt retry. - Alias-race loser retries DescribeKey on NotFoundException (winner's CreateAlias may not be visible yet) and logs a warning instead of silently ignoring a failed orphan-key ScheduleKeyDeletion. - The IfNoneMatch put retries on 409 ConditionalRequestConflict (S3 documents it as retryable; our put may not have committed). - Corrupt __state_key__.json and corrupt ~/.alchemy/state.key fail as typed StateStoreError, not JSON.parse/crypto defects. - makeStateReviver only decrypts exact single-key __secret__ envelopes; a user object containing the key alongside other fields is data. - SecretCodec rejects truncated v1 frames before touching the cipher (Node accepts short GCM tags) and keeps all crypto ops in the try. - Reads engage the codec only when the raw JSON carries a __secret__ marker, and a codec-resolution failure only surfaces when a codec-less parse cannot revive the state (substring false-positives are benign). Local writes resolve the key file only when the value holds a Redacted; state get/export never create the key file for secret-free output. - PostgresState now honors ALCHEMY_PASSWORD (opt-in, shared-store codec): encrypts Redacted values on write, revives __secret__ envelopes on read as typed errors instead of defects. Without the password its behavior is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he State barrel Rebase fixups for alchemy-run#1400 on current main: - `rootDir` is a function exported from Auth/Paths.ts (ALCHEMY_HOME-aware, resolved lazily), not a constant on Auth/Profile.ts. - `State/index.ts` no longer re-exports SecretCodec: the barrel is pulled into worker bundles via the core engine and SecretCodec loads node:crypto plus the ~/.alchemy path helpers. Stores deep-import it, matching the PR's own intent that node:crypto never reaches workerd bundles. - The CLI changes to `alchemy state get`/`export` were dropped in the rebase: those commands were replaced by `state read` (alchemy-run#1235), which decrypts through the store like any other reader. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pins the upgrade invariants for the local and Postgres stores: - legacy plaintext `__redacted__` resources, replaced rows and stack outputs read on a machine with no key file, and reads/secret-free writes never create `~/.alchemy/state.key` - encrypted and legacy entries coexist in one stage; a legacy entry only migrates on its next write - a lost machine key fails reads with a typed StateStoreError, leaves the state file byte-identical, and restoring the key restores reads - concurrent first-time writers converge on one 0600 key file - a wrong ALCHEMY_PASSWORD fails typed, never as a defect - an encrypted envelope is a plain single-key object to a pre-encryption reader, with no plaintext anywhere - Postgres: without ALCHEMY_PASSWORD nothing changes; with it, rows are encrypted, legacy rows still revive and re-encrypt on write, and a wrong/missing password fails typed Machine-key tests point ALCHEMY_HOME at a scoped temp dir (exclusive) so they never touch the developer's real key. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A lost or foreign machine key makes reads fail with a StateStoreError; it does not get "re-written on redeploy". Spell out the recovery options, the committed-`.alchemy/state` / CI footgun (set ALCHEMY_PASSWORD before upgrading), the Postgres opt-in, and that HTTP-backed stores never see envelopes. Reference `alchemy state read` (the current command). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
sam-goodwin
force-pushed
the
feat/state-secret-encryption
branch
from
September 1, 2026 21:44
4187d3e to
da9578b
Compare
sam-goodwin
marked this pull request as ready for review
September 1, 2026 22:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Re-implementation of #1030 (base had drifted to conflict), rebased onto current main. Opened by Claude (Fable) on behalf of @colelawrence.
State stores persist every
Redacted<T>as a plaintext{ "__redacted__": ... }marker. Secrets in state are now encrypted by default in the stores that own their key material, with nothing for the user to manage:.alchemy/state/): auto-generated 32-byte key at~/.alchemy/state.key(created on first use, mode 0600,wx-exclusive so concurrent creators converge). Repo state alone never exposes a secret.AWS.state()): KMS envelope encryption via the auto-managedalias/alchemy-statekey, with the KMS-wrapped data key at{prefix}__state_key__.json. Engaged lazily — the codec resolves on write only when the value contains aRedacted, on read only when the raw JSON carries a__secret__marker, so secret-free stacks never touch KMS (nokms:*permission, no CMK minted). Recovers the key from an out-of-band pending deletion. Opt out withsecretEncryption: "off".ALCHEMY_PASSWORDoverrides both with a scrypt-derived key.State/SecretCodec.ts): AES-256-GCM, random per-value IV,v1:framing. Sync by design (runs insideJSON.parse/stringifyrevivers); its own module sonode:cryptostays out of workerd bundles (StateEncodingimports it type-only).Redactedpayloads are ciphertext — the rest of state stays introspectable, and change detection is unaffected (secrets decrypt on read).StateStoreError(sharedstateDecodeError), never a defect or silent corruption.Rebase deltas vs. #1030
StateEncoding.tsmerged with the newerDATE_MARKERsupport;containsRedactedskipsDateleaves.LocalState.tsmerged with main's hardening (initialCwdanchoring, atomic temp-file writes,recoverMissingDir,deleteStackcache invalidation) — all preserved.alchemy state export(feat(cli): add bulk state read viaalchemy state export#1043, post-dates feat(state): encrypt secrets in state by default (auto local key, KMS on S3) #1030) printed plaintext secrets; it now prints__secret__envelopes via the same codec asstate get:resources: exported.resources.map((r) => ({ ...r, - state: encodeState(r.state), + state: encodeState(r.state, codec), })),PostgresState(feat(prisma): object storage (resources + bindings), locked Postgres state backend, Deployment.redeployOn #1061) writes rows client-side (unlike the HTTP store, the database never encrypts the JSON), but is shared across machines so it has no automatic key source. It now honorsALCHEMY_PASSWORDas an opt-in shared codec — encrypts on write, revives__secret__envelopes on read (as typedStateStoreErrors, never defects). Without the password its behavior is unchanged.Hardening from adversarial review
Three independent review passes (crypto, KMS/concurrency, integration) drove a hardening commit:
Effect.cachedpersists failure Exits forever, so a transient S3/KMS/fs failure would have poisoned every later secret operation.recoverKmsKeyrides out KMS eventual consistency (EnableKey retried through the post-CancelKeyDeletion window, then waits forKeyState=Enabled) instead of swallowingKMSInvalidStateExceptionand leaving the key Disabled.DescribeKeyonNotFoundExceptionand logs (instead of silently ignoring) a failed orphan-key cleanup; theIfNoneMatchput retries on 409ConditionalRequestConflictper S3's documented semantics.__state_key__.json/~/.alchemy/state.keyfail as typedStateStoreErrors, notJSON.parse/crypto defects; truncatedv1:frames are rejected before reaching the cipher (Node accepts short GCM tags).{ "__secret__": ... }envelopes — a user object merely containing the key is data. Reads engage the codec only when the raw JSON carries the marker, and a resolution failure surfaces only when a codec-less parse can't revive the state, so marker false-positives never demand KMS. Local writes touch the key file only when the value actually holds aRedacted;state get/exportstay side-effect-free for secret-free output.Known accepted trade-off: the scrypt salt is a fixed context string (documented in
SecretCodec.ts) —ALCHEMY_PASSWORDis expected to be high-entropy (docs showopenssl rand -base64 32), and a per-envelope salt would require av2:frame format.Compatibility
__redacted__markers still revive; the next write re-encrypts (explicit migration tests, local and S3).__secret__read the envelope as a plain object instead of failing. Readers of a shared store must upgrade together — called out in a:::cautionin the docs; should be in the release notes too.Tested: 14 hermetic encoding/keyfile/migration tests, plus live S3 tests covering the KMS flow end-to-end (raw-object ciphertext assertion, legacy roll-forward without engaging KMS, secret-free-never-touches-KMS, pending-deletion recovery).
Docs: rewritten "Secrets in state are encrypted" section on environments/secrets.
🤖 Generated with Claude Code
Rebase onto main (maintainer)
Rebased onto current
mainwith three follow-up commits:rootDiris nowrootDir()fromAuth/Paths.ts(ALCHEMY_HOME-aware);SecretCodecis deep-imported and not re-exported fromState/index.ts, sonode:cryptostays out of worker bundles.alchemy state get/exportchanges were dropped: those commands were replaced byalchemy state read(feat!: overhaul the Alchemy CLI #1235), which decrypts through the store like any other reader.test/State/SecretEncryptionCompat.test.ts(local store: keyless legacy reads never create a key, mixed legacy/encrypted stages, lost-key and wrong-password failure modes, concurrent key creation, forward-compat envelope shape) and Postgres compatibility tests intest/State/PostgresState.test.ts.StateStoreError(it is not "re-written on redeploy"); teams sharing.alchemy/state/or reading it from CI must setALCHEMY_PASSWORDbefore upgrading.